虚幻引擎4:RTS游戏Demo制作的十大基础技术分享 |
您所在的位置:网站首页 › 虚幻引擎4 教程 › 虚幻引擎4:RTS游戏Demo制作的十大基础技术分享 |
UE4这个玩具我接触了一年有余,这个靠FPS发家的引擎无疑是最成功的商业引擎之一。然而身为红警爱好者的我虽然因为工作和生活的原因已经弃坑RA3许久,但也没因为UE4而放弃对RTS的热爱,因此在这里我总结了一些UE4开发RTS的一些基础的技术。一来和大家分享这些劳动成果会很快乐,二来我自己如果再不写一些技术文章只怕我自己都会忘了这些(RA3的MOD制作技术我已经忘得差不多了,狗头)。由于口才太烂,而且UE4技术并不扎实,我就不去录制什么视频教程了,但我会总结我在研究RTS游戏时总结的一些开发技术。如果有兴趣一致的小伙伴,希望这篇文章也会帮到你们。 在写之前,笔者先在这里说明以下本文适合阅读的群体: 1.对游戏开发有足够兴趣和耐心的 2.对UE4有基本使用经验的(蓝图和C++都要了解) 3.高中数学基础不错的 注: 1. 缺少1或者2的。如果你没有兴趣和耐心,那么请直接alt+F4,那么这篇文章将会成为你的精神污染,这是你我都不愿意看到的;只具备1,3,但希望能从这里汲取一些你想要的知识,那么我在这里推荐谌嘉诚,ATMHRC两位老师,他们的教程真的很不错,当然,你还要并自己去学习一下UE4的C++教程再来看这篇文章;如果你只具备1,2点,但是高中数学基础一般,那么请仔细看一下这篇文章中的数学部分,我会尽自己最大努力讲解。 2.高三狗。我的强烈建议收藏后Alt+F4,然后来一本五年高考三年模拟。这个社会学历真的很重要,再好的UE4的开发技术也不值得用你的高考成绩来换取,天才和土豪可以无视这一条。 这里使用的引擎版本是4.25.2,当然,低版本的也可以参考学习。 一.GamePlay框架的设置和关卡的基本处理 在搭建关卡之前,我们需要创建以下类: BP_RTS_Mode:继承自GameMode类,暂时为纯数据类,不提供方法,用于加载PlayerController,PlayerPawn,HUD BP_RTS_Controller:继承自玩家控制器类(PlayerController类),用于提供玩家的操作指令(攻击,移动,选择等等) BP_MainCamera:继承自Pawn类,用于挂载相机,提供一个玩家的俯视图视角 BP_MainUI:继承自用户控件类,用于提供基础的操作界面,比如建筑生产序列,不过由于这个难度是入门级的,这里就不讲了。 关卡我们只需要添加一个平行光,和一张UE4地图编辑器随意编辑的地图就行了。注意,平行光请关闭阴影投射,动态投射阴影的性能开销真的是丧心病狂,阴影对于RTS影响不大,请在后期有良好优化的前提下开启,或者等虚幻5上Lumen吧(这里想提一下RA2的那薛定谔式阴影,估计使是使用贴花技术搞定的吧。。。)。
二.基类的选取和Actor组件结构 我们以红警2为模板,要为建筑,步兵,载具,可拦截飞弹选择一个共有基类,至少也是能接受AI控制器输入的Pawn类,这篇文章也在Pawn的基础上讲解。 但是对于游戏开发,我的建议是大家在开发的时候优先选择Character类,因为这个类聚集了EPIC的技术精华,地形检测,移动状态判定,动态避让(RVO和Detour),动画系统,这些东西要造轮子真的挺费事的。如果Character实在是臃肿到了没法做优化的地步,那再换成Pawn类吧。 三.PlayerPawn处理(相机处理) 一张图,自己看吧: 别开物理!!!!!四.PlayerController处理(鼠标框选和各种指令发送) RTS的单位选择操作是基于鼠标框选而完成的,UE4也提供了一些API来完成这个操作。ATMHRC老师就曾使用经典的方体空间扫描来完成检测,EPIC商城也有不少Demo使用HUD的 这个API来实现二维空间的对三位空间的矩形检测。 但是,我们是以RA2为模板来考虑这个问题的。矩形空间扫面的原理是利用鼠标点击事件记录的两个平面位置,转化为三维空间的位置并获取其对应的方向,构造射线检测地面,从而绘制一个矩形检测区域。这种检测手段对于在平坦地面上的单位是可以实现高精度检测的,但是对于飞行器会产生明显的精度丢失,飞行距离越高精度越差,适合魔兽这样的游戏,对于命令与征服和星际就捉襟见肘了。而HUD中"获取选项矩形中的Actor"这个节点,在玩家相机角度逐渐趋近水平的过程中,也会丢失检测精度。因此,这里我提供的检测方法是,将Actor的三维空间坐标转换为屏幕二维坐标,然后根据鼠标左键点击,释放两个事件记录的两个二维坐标(X1,Y1)和(X2,Y2)来判定(这里要保证X2>X1,且Y2>Y1),只要Actor的坐标(X,Y)满足以下条件就成功被检测:X>X1&&XY1&&YMouseLocation_2.X) { X1=MouseLocation_2.X; X2=MouseLocation_1.X; } else { X1=MouseLocation_1.X; X2=MouseLocation_2.X; } if(MouseLocation_1.Y>MouseLocation_2.Y) { X1=MouseLocation_2.Y; X2=MouseLocation_1.Y; } else { X1=MouseLocation_1.Y; X2=MouseLocation_2.Y; } 五.载具的地形匹配,移动和炮台检测问题 1.地形匹配 我相信不少朋友也做过坦克等载具移动的问题,但是大多数是使用UE4自带的物理载具组件来完成,或者手撸物理相关的代码来完成操作。这种方法用于FPS等Pawn比较少的游戏是很好的处理手段,因为效果足够真实。但是对于RTS这种大规模Pawn哲学♂互怼的场景,物理是真的不能开,否则CPU和显卡直接烤肉了。而且运动惯性,翻车的处理都是麻烦问题。如果一定要要开启物理,也一定要选择合适时机小规模开启(比如RA2中载具被炸得摇晃的情况下,被炸的载具可以适时开启一小会儿)。很不巧的是UE4没有符合要求的运动组件来协助我们开发,因此我们必须设计一个合理的地形检查系统来保证载具合理地放置在地面上。 载具要合理地放置在地面上,一是要和地面足够贴近,二是要放置角度要和地面地倾斜程度及方向相匹配,第一点好做,我们使用射线检测地面返回的Location值,取其Z值就可以轻松做到,然后在蓝图编辑器里面逐渐调整Mesh的位置就可以得到相对合理的效果了。问题在于第二点,我相信会难倒很多朋友,因此,我要在这里重点介绍一下向量的线乘(叉积公式)。 令a=(x1,y1,z1),b=(x2,y2,z2) a和b的数量积(点积公式)定义:ab= |a|*|b|*cosθ = x1*x2+y1*y2+z1*z2,θ为AB的夹角; a和b的向量积(叉积公式)定义:a*b=(y1*z2-z1*y2 , z1*x2-x1*z2 , x1*y2-y1*x2),其中,|a*b|=|a|*|b|*sinθ,θ为向量间的夹角,在数值上等于a和b围成的平行四边形面积。 向量叉积的性质: 令c=a*b,则-c=b*a(叉积满足反交换律)。 在右手空间坐标系中,我们可以使用右手定则来判定方向,然而UE4使用的左手空间坐标系,我们必须使用左手定则来判定方向。 左手定则:左手四指握拳,伸出拇指并使拇指垂直于两a和b,四指的旋转弯曲方向为从a到b的按小于180°的转向方向(百度的),则拇指指向的方向即为乘积向量的方向。 既然我们知道了叉积公式,那我们应该怎样使用它呢? 首先,我们需要对载具正下方的地面进行法线查询,为了使结果更准确,我们可以取其中三个点来计算近似的平均法线: 如图,我们可以取三个点的位置,从这里开始垂直向下进行射线检测,得到三个点的坐标,A,B,C。然后得到两个向量,B-C和A-C,令向量v=(B-C)*(A-C),即图中蓝色向量。得到蓝色向量后,我们还需要求解俯仰角Pitch和滚动角Roll,最后使用SetActorRotation()进行设置。俯仰角Pitch求解:使用GetActorRightVector()获取指向Actor右侧的单位向量,这里记为向量r,再使用叉积公式r*v,结果记录为向量P,P=(x,y,z),则向量P和水平面的夹角角度为最终的Pitch值,Pitch=arctan[z/(x*x+y*y)^(1/2)](向量和水平面的夹角总该会算吧)。。。 滚动角Roll求解:同俯仰角的求解方法基本一致,我们可以使用GetActorForwardVector()获取指向Actor前方的向量,记为向量f,使用叉积公式f*v,结果记为向量R,R=(x,y,z),则向量R和水平面的夹角角度为最终的Roll值,Roll=arctan[z/(x*x+y*y)^(1/2)]。最后我们可以直接使用这两个值来设置Actor的欧拉角。C++代码如下: void ABaseVehicle::UpdateRotationToPatchLand()//地形匹配 { FHitResult Result; Start_01 = { GetActorLocation().X+40,GetActorLocation().Y,GetActorLocation().Z+100}; End_01= { GetActorLocation().X + 40,GetActorLocation().Y,GetActorLocation().Z - 1000 }; Start_02 = { GetActorLocation().X - 40,GetActorLocation().Y+30,GetActorLocation().Z + 100 }; End_02 = { GetActorLocation().X -40,GetActorLocation().Y+30,GetActorLocation().Z - 1000 };//在Actor附近选择射线起点 Start_03 = { GetActorLocation().X -40,GetActorLocation().Y-30,GetActorLocation().Z + 100 };//在Actor附近选择射线起点 End_03 = { GetActorLocation().X -40,GetActorLocation().Y-30,GetActorLocation().Z - 1000 };//在Actor附近选择射线起点 GetWorld()->LineTraceSingleByChannel(Result, Start_01, End_01, ECC_Camera);//射线检测地面地点 Location_01 = Result.Location; GetWorld()->LineTraceSingleByChannel(Result, Start_02, End_02, ECC_Camera); Location_02 = Result.Location; GetWorld()->LineTraceSingleByChannel(Result, Start_03, End_03, ECC_Camera); Location_03 = Result.Location; float AVG_Hight = (Location_01.Z + Location_02.Z) / 2;//求解合适高度 FVector AVG_Normal = GetNormalVector(Location_02 - Location_01, Location_03 - Location_01); float PitchAngle=GetVectorAngleWithFloor(GetNormalVector(GetActorRightVector(), AVG_Normal));//求解俯仰角 float RollAngle = GetVectorAngleWithFloor(GetNormalVector(GetActorForwardVector(), AVG_Normal));//求解滚动角 FRotator FinalRotation = { 0,0,0 }; FinalRotation = { PitchAngle,GetActorRotation().Yaw,RollAngle };//最终欧拉角 SetActorRotation(FinalRotation);//设置最终欧拉角 if (GetActorLocation().Z - AVG_Hight >= MovingHight || GetActorLocation().Z - AVG_HightGetTimerManager().SetTimer(FireTimeHandle,this,&AMyTank::Fire,0.1f,true,0.0f),需要头文件#include "TimerManager.h" 参数列表: 管理的计时器结构体,为FTimerHandle,可以直接FTimerHandle b实例化; 使用计时器的对象指针,一般为this 计时器调用的函数,传入函数指针,函数返回值和参数列表必须为空 调用函数的频率,float 是否循环,bool 第一次调用时的延迟,float
清理计时器:GetWorld()->GetTimerManager().ClearTimer(FireTimeHandle) 参数只有一个,为计时器结构体FTimerHandle,该函数并非清空结构体所占据的内存,而是清理计时器被SetTimer()方法设置的内容 第一个问题很简单,我就不做解答了,现在来讨论第二个: 运动中的方向匹配使用定时器每隔一段时间调用函数使朝向匹配速度方向就可以了,但是有一个问题我们需要处理,就是启动时朝哪个方向旋转最省时?这里我不做推导,直接给出结论: 1.当(路径朝向Yaw-车体朝向Yaw)的Yaw值在0到180或者小于-180时,炮台应当正向旋转,否则逆向旋转。(这里的旋转正向是指AddActorLocalRotation()输入的参数中,Yaw>0)当Yaw差值足够小时,可直接调用SetActorRotation()。 2.根据左手定则,路径方向向量v和Actor指向前方的向量f进行叉积运算,得到向量d(其中d=v*f),则向量d的z值如果大于0,则应逆向旋转,否则正向旋转。当向量d的模长约等于0的时候,可直接调用SetActorRotation()。(原因请参考前面的向量叉积公式说明) 理论上来讲,第一种方法易于理解,第二种方法性能更高。 这里采用第一种方法。 基于这两点规律,我们在处理载具移动过程中,首先要捕获移动的路径节点,处理启动转向的问题,可使用该蓝图API处理: 为了降低耦合,我们可以使用异步节点来处理这个问题(所谓异步节点就是左上角带有时钟标志的蓝图节点,它通过定时器或多线程完成异步操作,在运行时不会阻塞主线程)。异步蓝图节点创建方法如下: 创建继承自UBlueprintAsyncActionBase的C++类 定义委托,传递需要执行的蓝图节点 定义静态函数,让其可被蓝图调用 C++代码如下: UUnitRotateToRotation.h #pragma once #include "CoreMinimal.h" #include "BaseVehicle.h" #include "Kismet/BlueprintAsyncActionBase.h" #include "UnitRotateToRotation.generated.h" DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FDelegateRotateToLocationResult, int32, Result);//委托类的宏声明 UCLASS() class MYRTS_API UUnitRotateToRotation : public UBlueprintAsyncActionBase { GENERATED_BODY() public: UUnitRotateToRotation(); UFUNCTION(BlueprintCallable, meta = (BlueprintInternalUseOnly = "true")) static UUnitRotateToRotation* RotateToLocation(ABaseVehicle* Vehicle, FVector Location); UFUNCTION() void StartRotating(FVector NextLocation); UFUNCTION() void RotateAdd(); UFUNCTION() void RotateReduce(); UPROPERTY() ABaseVehicle* TargetVechicle; UPROPERTY(BlueprintAssignable) FDelegateRotateToLocationResult OnSuccess;//创建委托类 }; UUnitRotateToRotation.cpp #include "UnitRotateToRotation.h" UUnitRotateToRotation::UUnitRotateToRotation() { TargetVechicle = nullptr; } UUnitRotateToRotation* UUnitRotateToRotation::RotateToLocation(ABaseVehicle* Vehicle, FVector Location) { if (Vehicle->UnitRotatingToLocation == nullptr || Vehicle->UnitRotatingToLocation->IsPendingKill())//如果载具中的异步类指针UnitRotatingToLocation 无效,则创建异步类 { Vehicle->UnitRotatingToLocation = NewObject(); } Vehicle->UnitRotatingToLocation->TargetVechicle = Vehicle;//载具中具有定义该指向异步类的指针,为的是方便进行生命周期管理,因为异步节点是靠类来实现的,不严格管理生命周期可能会导致内存泄漏或者委托自身等异常问题 Vehicle->UnitRotatingToLocation->StartRotating(Location); return Vehicle->UnitRotatingToLocation; } void UUnitRotateToRotation::StartRotating(FVector NextLocation) { if (TargetVechicle != nullptr && !TargetVechicle->IsPendingKill()) { TargetVechicle->FirstLocation = NextLocation; float DeltaYaw = UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->FirstLocation - TargetVechicle->GetActorLocation()).Yaw - TargetVechicle->GetActorRotation().Yaw;//计算目标地点和自身的角度差 if (TargetVechicle->bIsMovingBack) { DeltaYaw = UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->GetActorLocation() - TargetVechicle->FirstLocation).Yaw - TargetVechicle->GetActorRotation().Yaw; } if ((DeltaYaw > 0.0f && DeltaYaw < 180.0f) || (DeltaYaw GetWorld()->GetTimerManager().SetTimer(TargetVechicle->JustRotationStartMove, this, &UUnitRotateToRotation::RotateAdd, 0.033f, true, 0.0f); TargetVechicle->SetMoveState(EnumMoveState::E_TurnRight);//定时器循环调用方向修正函数 } else { TargetVechicle->GetWorld()->GetTimerManager().SetTimer(TargetVechicle->JustRotationStartMove, t, &UUnitRotateToRotation::RotateReduce, 0.033f, true, 0.0f); TargetVechicle->SetMoveState(EnumMoveState::E_TurnLeft); } } else { MarkPendingKill(); } } void UUnitRotateToRotation::RotateAdd() { if (TargetVechicle != nullptr && !TargetVechicle->IsPendingKill()) { float DeltaYaw = UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->FirstLocation - TargetVechicle->GetActorLocation()).Yaw - TargetVechicle->GetActorRotation().Yaw;//计算目标地点和自身的角度差 float ABS_DeltaYaw = FMath::Abs(DeltaYaw); if (TargetVechicle->bIsMovingBack) { DeltaYaw = UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->GetActorLocation() - TargetVechicle->FirstLocation).Yaw - TargetVechicle->GetActorRotation().Yaw; ABS_DeltaYaw = FMath::Abs(DeltaYaw); } if ((ABS_DeltaYaw RotateSpeed) || ((ABS_DeltaYaw >= (360.0 - TargetVechicle->RotateSpeed))))//炮台与目标角度小于炮台旋转角度时直接设置朝向,否则按原有速度旋转 { FRotator FinalRotation = { 0,0,0 }; if (TargetVechicle->bIsMovingBack) { FinalRotation = { TargetVechicle->GetActorRotation().Pitch,UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->GetActorLocation() - TargetVechicle->FirstLocation).Yaw, TargetVechicle->GetActorRotation().Roll }; } else { FinalRotation = { TargetVechicle->GetActorRotation().Pitch,UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->FirstLocation - TargetVechicle->GetActorLocation()).Yaw, TargetVechicle->GetActorRotation().Roll }; } TargetVechicle->SetActorRotation(FinalRotation); TargetVechicle->GetWorld()->GetTimerManager().ClearTimer(TargetVechicle->JustRotationStartMove); OnSuccess.Broadcast(0);//TargetVechicle->TankMoving(); } else { FRotator AddedRotation = { 0,TargetVechicle->RotateSpeed,0 }; TargetVechicle->AddActorLocalRotation(AddedRotation); } } else { MarkPendingKill(); } } void UUnitRotateToRotation::RotateReduce() { if (TargetVechicle != nullptr && !TargetVechicle->IsPendingKill()) { float DeltaYaw = UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->FirstLocation - TargetVechicle->GetActorLocation()).Yaw - TargetVechicle->GetActorRotation().Yaw;//计算目标地点和自身的角度差 float ABS_DeltaYaw = FMath::Abs(DeltaYaw); if (TargetVechicle->bIsMovingBack) { DeltaYaw = UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->GetActorLocation() - TargetVechicle->FirstLocation).Yaw - TargetVechicle->GetActorRotation().Yaw; ABS_DeltaYaw = FMath::Abs(DeltaYaw); } if ((ABS_DeltaYaw RotateSpeed) || ((ABS_DeltaYaw >= (360.0 - TargetVechicle->RotateSpeed))))//炮台与目标角度小于炮台旋转角度时直接设置朝向,否则按原有速度旋转 { FRotator FinalRotation = { 0,0,0 }; if (TargetVechicle->bIsMovingBack)//如果载具倒退 { FinalRotation = { TargetVechicle->GetActorRotation().Pitch,UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->GetActorLocation() - TargetVechicle->FirstLocation).Yaw, TargetVechicle->GetActorRotation().Roll }; } else { FinalRotation = { TargetVechicle->GetActorRotation().Pitch,UKismetMathLibrary::Conv_VectorToRotator(TargetVechicle->FirstLocation - TargetVechicle->GetActorLocation()).Yaw, TargetVechicle->GetActorRotation().Roll }; } TargetVechicle->SetActorRotation(FinalRotation); TargetVechicle->GetWorld()->GetTimerManager().ClearTimer(TargetVechicle->JustRotationStartMove); OnSuccess.Broadcast(0);//启动时方向匹配成功后,触发委托,调用蓝图函数 } else { FRotator AddedRotation = { 0,-1.0f * TargetVechicle->RotateSpeed,0 }; TargetVechicle->AddActorLocalRotation(AddedRotation); } } else { MarkPendingKill();//销毁自身 } } 在蓝图中效果如下: 注: 如果要移动向空军,可以给空军单位配置一个虚拟Actor,位置s匹配在空军单位正下方的地面上,该Actor用作移动的目标。因为UE4的导航系统是不支持向空中移动的。 如果要让空军移动向目标或者地点,可以使用定时器配合AddMovementInput,输入一个合理的向量(地点或者目标的Location直接和自身Location做差就可以了,要记得重置差向量的Z值为空军自身的位置Z值,否则就俯冲了)。
3.炮台转动及侦察敌军问题 炮台的转动和载具转动一致,我就不再多讲了。但是炮台寻敌问题需要注意以下: 1.炮台的检测应当采用柱体检测,而非球体检测(原因很简单,如果使用球体检测,空军从你头上飞过你的防空武器都有可能检测不到)。这个可以自行解决,算法很简单,两者距离的平方小于武器射程的平方就可以认为被检测到了。如果要使用引擎API,请使用球体扫描检测,在跨度较大的Z轴范围内检测 2.炮台寻敌应当使用所有武器中射程最大的作为判断参数。 3.如果使用使用球体扫描检测,可在检测前使用包围盒预检测,即:如果在包围盒内,则进行柱体检测(因为包围盒检测的开销是要低于使用球体扫描的,球体扫描检测为胶囊体形状,计算过程远复杂于包围盒,包围盒只需要进行四个判断,而且绝大多数往往判断不到四次,算法大佬可以手撸四叉树)。包围盒为正方形,其边长等于半径的平方,和球体扫描空间相内切。 4.检测依然使用定时器来完成,而不是Tick(Tick如果限制频率倒是可以),Tick的开销太高,而且再不同性能的设备上具有不同的触发频率,很可能产生不同效果,编辑器环境下默认锁定60帧。 八.材质问题(阵营色处理) 这里先上两张图: 材质阵营色蓝图首先,我们除了要用到基本的漫反射贴图和法线贴图外,还需额外引入一张黑白贴图,黑白贴图白色部分用于标记阵营色,算法原理很简单: 基础贴图和对比贴图做乘法运算,得到要中间贴图(黑色为0,白色1,其他颜色为中间值) 然后用基础贴图减去中间贴图,就可以将其要设置为阵营色的部分标记为黑色,成为待染色基本贴图 对比贴图乘以阵营色,可以将对比贴图白色部分染成阵营色 染色后的对比贴图和待染色基本贴图求和,即可得到想要的材质 将阵营色TeamColor设置为参数(选中,右键菜单找) 蓝图中设置材质实例,可通过名称访问并修改这个阵营色参数 九.动态避让问题 UE4中,Pawn是无法动态避让的,无论是RVO,Detour均不可用,但Character可以,这也是我为什么在前面会推荐大家选择Character的原因,但是选择Character后碾压问题请自行处理了。。。因此,我们需要自己写一套避让算法,找寻合适的路径。这里我们可以模仿AStar算法计算避让路径,路径点有三个就足以避让了,算法功力深厚的大佬可以自己手撸Detour和RVO。 1.确认八个避让方向 1为优先考虑的方向,8为最后考虑的方向。这样设置,主要是保证互逆的两个方向在ID上互补为9,方便清除原路返回的路径。 2.创建路径节点类AvoidPathPoint 定义如下: AvoidPathPoint.h #pragma once #include "Vector.h" #include "Array.h" #include "CoreMinimal.h" /** * */ class MYRTS_API AvoidPathPoint { public: AvoidPathPoint(); ~AvoidPathPoint(); FVector PathPoint; TArray ForbiddenDirectionID;//存储不可到达的方向ID bool CheckLocationID(int LocationID);//检查该方向ID是否不可到达 }; AvoidPathPoint.cpp #include "AvoidPathPoint.h" AvoidPathPoint::AvoidPathPoint() { PathPoint = { 0.0f,0.0f,0.0f }; //PathPointLable = 1; } bool AvoidPathPoint::CheckLocationID(int LocationID) { bool IsValid = true; if (ForbiddenDirectionID.Num() >= 1&& ForbiddenDirectionID.Num() |
今日新闻 |
推荐新闻 |
CopyRight 2018-2019 办公设备维修网 版权所有 豫ICP备15022753号-3 |